SPB Git forge

spb/qc26

Public
10commits 1branches 0releases
2.8 MBsize
maindefault branch
17 days agolast push
Python 51.6% TypeScript 46.7% CSS 1.7%
10.4 KB · 73 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { apiSafe, type Meta, type PartyForecast } from "@/lib/api";5import { SeatDistribution } from "@/components/charts";6import { PartyLogo } from "@/components/party";7import { Badge, Card, Dual, Empty, KV, SectionHeader } from "@/components/ui";8import { dateFr, int, pct, prob } from "@/lib/format";9import { PARTIES, partyLabel } from "@/lib/parties";1011export const revalidate = 120;12export function generateStaticParams() { return PARTIES.map((p) => ({ id: p.id })); }1314interface Detail { id: string; abbreviation: string; officialName: string; color: string; website: string; leader: string | null; leaderSince: string | null; dgeqName: string | null; candidatesCount: number; seats2022: number; vote2022: number | null; colorNote: string;15  forecast: PartyForecast | null; strongholds: { code: number; name: string; slug: string; p: number; region: string }[]; targets: { code: number; name: string; slug: string; margin: number; favorite: string }[]; regions: Record<string, number>;16  candidates: { riding: number; name: string; incumbent: boolean }[]; documents: { id: number; title: string; url: string; type: string; date: string | null; proposals: number }[]; topics: Record<string, number>; axisProfile: Record<string, { value: number; n: number }>; history: Record<string, number> }1718export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {19  const { id } = await params;20  const p = PARTIES.find((x) => x.id === id);21  return { title: p ? `${p.officialName} — projection, candidatures, documents` : "Parti" };22}2324export default async function Page({ params }: { params: Promise<{ id: string }> }) {25  const { id } = await params;26  const [d, meta] = await Promise.all([apiSafe<Detail>(`/api/parties/${id}`), apiSafe<Meta>("/api/meta")]);27  if (!d) notFound();28  if (!meta) return <Empty title="Indisponible" />;29  const f = d.forecast;30  const topics = Object.entries(d.topics).sort((a, b) => b[1] - a[1]).slice(0, 8);31  return (32    <div className="space-y-10">33      <div className="card card-lg p-6 md:p-8" style={{ borderTop: `5px solid ${d.color}` }}>34        <div className="flex items-start justify-between gap-4 flex-wrap">35          <div><Link href="/partis" className="text-[12.5px] text-ink-3 hover:text-ink">← Partis</Link><div className="mt-3"><PartyLogo id={d.id} height={40} /></div><h1 className="display text-[34px] md:text-[48px] mt-3">{d.officialName}</h1><div className="text-[14px] text-ink-2 mt-2">Chef·fe (registre DGEQ) : <b className="text-ink">{d.leader ?? "—"}</b>{d.leaderSince ? ` · depuis le ${dateFr(d.leaderSince)}` : ""}{d.dgeqName && d.dgeqName !== d.officialName ? <span className="text-ink-3"> · nom au registre : {d.dgeqName}</span> : null}</div><a href={d.website} target="_blank" rel="noopener noreferrer" className="link text-[13px] mt-1 inline-block">{d.website.replace(/^https?:\/\//, "")}</a></div>36          {f && (37            <div className="grid grid-cols-2 sm:grid-cols-4 gap-x-6 sm:gap-x-8 gap-y-3 w-full sm:w-auto">38              <div><div className="eyebrow">Sièges</div><div className="figure text-[38px] sm:text-[44px]" style={{ color: d.color }}>{f.seatsMedian}</div><div className="text-[12px] text-ink-3 num">80 % {f.seats80[0]}–{f.seats80[1]}</div></div>39              <div><div className="eyebrow">Vote</div><div className="figure text-[38px] sm:text-[44px]">{f.voteMean.toFixed(1).replace(".", ",")}</div><div className="text-[12px] text-ink-3 num">± {f.voteSd.toFixed(1).replace(".", ",")} · 2022 : {pct(d.vote2022)}</div></div>40              <div><div className="eyebrow">Majorité</div><div className="figure text-[38px] sm:text-[44px]">{prob(f.pMajority)}</div><div className="text-[12px] text-ink-3">seuil {meta.majority}</div></div>41              <div><div className="eyebrow">Plus de sièges</div><div className="figure text-[38px] sm:text-[44px]">{prob(f.pMostSeats)}</div><div className="text-[12px] text-ink-3 num">2022 : {d.seats2022} sièges</div></div>42            </div>43          )}44        </div>45        {f?.seatDistribution && <div className="mt-6 max-w-[760px]"><Dual narrow={<SeatDistribution dist={f.seatDistribution} color={d.color} majority={meta.majority} median={f.seatsMedian} lo80={f.seats80[0]} hi80={f.seats80[1]} lo95={f.seats95[0]} hi95={f.seats95[1]} height={90} total={meta.seatsTotal} width={340} />} wide={<SeatDistribution dist={f.seatDistribution} color={d.color} majority={meta.majority} median={f.seatsMedian} lo80={f.seats80[0]} hi80={f.seats80[1]} lo95={f.seats95[0]} hi95={f.seats95[1]} height={90} total={meta.seatsTotal} />} /></div>}46      </div>4748      <section className="grid lg:grid-cols-3 gap-6">49        <div><SectionHeader eyebrow="Carte des forces" title="Bastions" description="Circonscriptions où le parti est favori, par probabilité." /><Card pad={false} className="max-h-[420px] overflow-auto"><table className="data-table"><tbody>{d.strongholds.slice(0, 40).map((r) => <tr key={r.code}><td><Link href={`/circonscription/${r.slug}`} className="hover:underline">{r.name}</Link></td><td className="r num font-semibold whitespace-nowrap">{prob(r.p)}</td></tr>)}{d.strongholds.length === 0 && <tr><td className="text-ink-3 text-center py-6">Aucune circonscription où le parti est favori.</td></tr>}</tbody></table></Card><div className="text-[12px] text-ink-3 mt-1 num">{d.strongholds.length} circonscriptions</div></div>50        <div><SectionHeader eyebrow="Cibles" title="Meilleures chances de gain" description="Circonscriptions où le parti est 2e, classées par marge projetée." /><Card pad={false}><table className="data-table"><tbody>{d.targets.map((r) => <tr key={r.code}><td><Link href={`/circonscription/${r.slug}`} className="hover:underline">{r.name}</Link></td><td className="text-ink-3 text-[12px]">derrière {partyLabel(r.favorite)}</td><td className="r num whitespace-nowrap">{r.margin.toFixed(1).replace(".", ",")} pt</td></tr>)}</tbody></table></Card></div>51        <div><SectionHeader eyebrow="Régions" title="Vote projeté par région" description="Moyenne des parts projetées des circonscriptions de la région." /><Card><div className="space-y-1.5">{Object.entries(d.regions).sort((a, b) => b[1] - a[1]).map(([g, v]) => (<div key={g} className="grid grid-cols-[1fr_60px] items-center gap-3 text-[12.5px]"><div><div className="flex justify-between"><span className="capitalize">{g.replace(/-/g, " ")}</span></div><div className="h-[5px] bg-surface-3 rounded-full mt-0.5 overflow-hidden"><div className="h-full" style={{ width: `${v * 1.6}%`, background: d.color }} /></div></div><span className="num text-right font-semibold">{v.toFixed(0)} %</span></div>))}</div></Card></div>52      </section>5354      <section className="grid lg:grid-cols-[1fr_1fr] gap-6">55        <div>56          <SectionHeader eyebrow="Plateforme" title="Documents et positions" description="Documents officiels ingérés depuis le site du parti ; propositions extraites avec page et citation." action={<Link href={`/documents?party=${d.id}`} className="link text-[13.5px]">Tous les documents →</Link>} />57          <Card pad={false}>{d.documents.length === 0 ? <div className="p-6 text-ink-3 text-[13.5px] text-center">Aucun document analysé pour l&apos;instant : le connecteur documentaire vérifie le site officiel toutes les deux heures.</div> : <table className="data-table"><thead><tr><th>Document</th><th>Type</th><th className="r">Propositions</th></tr></thead><tbody>{d.documents.slice(0, 12).map((x) => <tr key={x.id}><td><Link href={`/documents/${x.id}`} className="hover:underline font-medium">{x.title}</Link></td><td><Badge>{x.type}</Badge></td><td className="r num">{x.proposals}</td></tr>)}</tbody></table>}</Card>58          {topics.length > 0 && <div className="mt-3 flex flex-wrap gap-1.5">{topics.map(([t, n]) => <Link key={t} href={`/enjeux/${t}`} className="text-[12.5px] rounded-full border border-border px-2.5 py-1 hover:bg-surface-2">{meta.topics[t] ?? t} <span className="num text-ink-3">{n}</span></Link>)}</div>}59        </div>60        <div>61          <SectionHeader eyebrow="Boussole" title="Profil sur les axes" description="Position moyenne du parti sur chaque axe, dérivée de ses propositions officielles (−100 = pôle gauche de l'axe, +100 = pôle droit)." />62          <Card>{Object.keys(d.axisProfile).length === 0 ? <div className="text-ink-3 text-[13.5px]">Positions en cours d&apos;extraction.</div> : <div className="space-y-3">{Object.entries(meta.axes).map(([axis, a]) => { const v = d.axisProfile[axis]; return (<div key={axis} className="text-[12.5px]"><div className="flex justify-between text-ink-3"><span>{a.left}</span><b className="text-ink">{a.label}</b><span>{a.right}</span></div><div className="relative h-[10px] bg-surface-3 rounded-full mt-1"><div className="absolute top-0 bottom-0 left-1/2 w-px bg-ink-3" />{v && <div className="absolute top-[-3px] w-4 h-4 rounded-full border-2 border-white shadow" style={{ left: `calc(${50 + v.value / 2}% - 8px)`, background: d.color }} title={`${v.value} (${v.n} énoncés)`} />}</div>{!v && <div className="text-[11px] text-ink-3 mt-0.5">Aucun énoncé documenté.</div>}</div>); })}</div>}</Card>63        </div>64      </section>6566      <section className="grid md:grid-cols-2 gap-6">67        <div><SectionHeader eyebrow="Candidatures" title={`${int(d.candidatesCount)} candidatures officielles`} description="Liste DGEQ (mise à jour continue jusqu'à la clôture)." /><Card pad={false} className="max-h-[400px] overflow-auto"><table className="data-table"><tbody>{d.candidates.map((c) => <tr key={c.riding + c.name}><td>{c.name}{c.incumbent && <span className="ml-1.5 text-[10.5px] uppercase text-ink-3">sortant·e</span>}</td><td className="r"><Link href={`/circonscription/${c.riding}`} className="link text-[12.5px]">fiche</Link></td></tr>)}</tbody></table></Card></div>68        <Card><div className="eyebrow mb-2">Fiche</div><KV items={[["Sigle", d.abbreviation], ["Nom officiel (REPAQ)", d.dgeqName ?? d.officialName], ["Chef·fe", d.leader ?? "—"], ["Sièges 2022", String(d.seats2022)], ["Vote 2022", pct(d.vote2022)], ["Historique (élu·es)", Object.entries(d.history).sort().map(([y, n]) => `${y.slice(0, 4)} : ${n}`).join(" · ") || "—"], ["Couleur QC26", <span key="c" className="inline-flex items-center gap-2"><span className="w-3 h-3 rounded-sm" style={{ background: d.color }} />{d.color} <span className="text-ink-3 text-[12px]">{d.colorNote}</span></span>]]} /></Card>69      </section>70    </div>71  );72}73